레시피 공유 페이지 만들기
✒️ 2025-07-08 21:53 내용 수정
- 부트캠프 수업에서 진행한 실습 코드를 그대로 정리했다.
- 수업 때 진행한 내용에서 새로 알게된 내용을 추가로 메모하고 있다.
실습 목표
- Spring Boot를 사용해서 레시피를 공유하는 사이트를 제작한다.
- 페이지 구성
- 목록 페이지
- 상세 페이지
- 등록 페이지
- 인증 페이지
- 핵심 기능
- 사용자 인증 시스템 : 회원 가입, 로그인/로그아웃, 접근 제어
- 레시피 관리 : 등록, 목록, 상세 보기, 검색
- 상호작용 기능 : 좋아요, 댓글, 태그
- 반응형 디자인, 에러 처리
프로젝트 설정
의존성
- Spring Web, Thymeleaf, JPA, Spring Validation, H2 의존성을 추가한다.
- DB 연동을 진행하진 않고 연동을 위한 JPA와 Entity 관련 실습 코드만 진행했다.
<!-- spring web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- thymeleaf -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- jpa -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- h2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
디렉터리 구조
- MVC 패턴에 따라 Controller, View, Model로 역할을 나눈다.
- Model에는 Entity를 저장했고, JPA의 Repository는 Repository 디렉터리에 추가했다.
- DB와의 상호작용 및 비즈니스 로직은 Service에서 담당하며, Service는 Interface로 만든 후 클래스로 구현하는 방식으로 관리한다.
spring_day6/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── com/
│ │ │ ├── example/
│ │ │ ├── spring_day6/
│ │ │ ├── controller/
│ │ │ │ ├── AuthController.java
│ │ │ │ ├── HomeController.java
│ │ │ │ └── RecipeController.java
│ │ │ ├── model/
│ │ │ │ ├── Comment.java
│ │ │ │ ├── Recipe.java
│ │ │ │ ├── Tag.java
│ │ │ │ └── User.java
│ │ │ ├── repository/
│ │ │ │ ├── CommentRepository.java
│ │ │ │ ├── RecipeRepository.java
│ │ │ │ ├── TagRepository.java
│ │ │ │ └── UserRepository.java
│ │ │ ├── service/
│ │ │ │ ├── AuthService.java
│ │ │ │ ├── AuthServiceImpl.java
│ │ │ │ ├── CommentService.java
│ │ │ │ ├── CommentServiceImpl.java
│ │ │ │ ├── RecipeService.java
│ │ │ │ ├── RecipeServiceImpl.java
│ │ │ │ ├── TagService.java
│ │ │ │ └── TagServiceImpl.java
│ │ │ └── SpringDay6Application.java
│ │ ├── resources/
│ │ ├── templates/
│ │ │ ├── index.html
│ │ │ ├── login.html
│ │ │ ├── recipe_detail.html
│ │ │ ├── recipe_form.html
│ │ │ ├── recipes.html
│ │ │ └── register.html
│ │ └── application.properties
│
├── uploads/
├── mvnw
├── mvnw.cmd
└── pom.xml
MVC 패턴
Model
- Entity는
@EntityAnnotation으로 Entity임을 명시한다. - Primary key 역할의 필드에는
@Id를 추가한다. - 기본 생성자를 포함하며, 각 필드를
private으로 만들어 캡슐화하기 때문에 getter와 setter가 필요하다.
Entity - User
- 사용자 정보를 담는 Entity로, 이 실습에선
Comment와Recipe와의 테이블 관계를 각각 추가한다.Comment와는1:N,Recipe와는N:M관계를 가진다.
- 이후에 Set으로 User를 저장할 때 객체 비교로 사용할
equals와hashCode함수를 오버라이딩하여 수정한다.
package com.example.spring_day6.model;
import jakarta.persistence.*;
import java.util.List;
import java.util.Set;
import java.util.Objects;
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String username;
private String password;
// Comment에 있는 user 필드로 매핑됨
@OneToMany(mappedBy = "user")
private List<Comment> comments;
@ManyToMany
private Set<Recipe> favorites;
public User() {}
public User(String username, String password) {
this.username = username;
this.password = password;
}
// getter와 setter
// equals와 hashCode 추가 (Set에서 중복 제거를 위해 필요)
@Override
public boolean equals(Object o) {
// 두 User 객체 비교 시 같으면 true
if (this == o) return true;
// 객체가 비어있거나 클래스가 다르면 false
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
// 두 객체의 사용자 이름이 같은지 확인
return Objects.equals(username, user.username);
}
@Override
public int hashCode() {
return Objects.hash(username);
}
}
Entity - Recipe
- 레시피 정보를 저장할 Entity이다.
- 저자 정보인 author(
User)와는N:1관계(저자가 1) Comment와는1:N관계(레시피가 1)Tag와는N:M관계- 좋아요를 저장한
User정보와는N:M관계
- 저자 정보인 author(
package com.example.spring_day6.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
@Entity
public class Recipe {
@Id
@GeneratedValue
private Long id;
private String title;
@Column(length = 5000)
private String description;
private LocalDateTime createdAt;
@ManyToOne
private User author; // 작성자 추가
@OneToMany(mappedBy = "recipe")
private List<Comment> comments;
@ManyToMany
private Set<Tag> tags;
@ManyToMany
private Set<User> likes;
public Recipe() {}
// getter와 setter
}
Entity - Comment
- 댓글 정보를 저장하는 Entity로, 이전에 다른 Entity와
N:1관계로 이어져있다.
package com.example.spring_day6.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
public class Comment {
@Id
@GeneratedValue
private Long id;
@ManyToOne
private Recipe recipe;
@ManyToOne
private User user;
@Column(length = 1000)
private String content;
private LocalDateTime createdAt;
public Comment() {}
// getter와 setter
}
Entity - Tag
- 태그 정보를 저장한 Entity로,
Recipe의 tag 필드로 mapping되어 있다.
package com.example.spring_day6.model;
import jakarta.persistence.*;
import java.util.Set;
@Entity
public class Tag {
@Id
@GeneratedValue
private Long id;
private String name;
@ManyToMany(mappedBy = "tags")
private Set<Recipe> recipes;
public Tag() {}
// getter와 setter
}
repository
- JPA의 Repository 중 CRUD와 정렬 등의 기능을 제공하는
JpaRepository를 상속 받아 각 Enitty별 Repository 인터페이스를 생성한다. - 실습에서는 인메모리 방식으로 구현하기에 실제로 사용하지 않았다.
UserRepository
package com.example.spring_day6.repository;
import com.example.spring_day6.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
// 인메모리 방식으로 변경했으므로 실제로는 사용하지 않음
}
RecipeRepository
package com.example.spring_day6.repository;
import com.example.spring_day6.model.Recipe;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RecipeRepository extends JpaRepository<Recipe, Long> {
Page<Recipe> findByTitleContaining(String keyword, Pageable pageable);
}
CommentRepository
package com.example.spring_day6.repository;
import com.example.spring_day6.model.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long> {
}
TagRepository
package com.example.spring_day6.repository;
import com.example.spring_day6.model.Tag;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TagRepository extends JpaRepository<Tag, Long> {
Tag findByName(String name);
}
Service
- Service는 DB와 상호 작용하며 실제 비즈니스 로직을 담당하는 interface와 클래스다.
- interface로 해당 Service가 제공할 메서드를 미리 설정하고, 구현 클래스에서 상세 로직을 작성한다.
AuthService
- AuthService
package com.example.spring_day6.service;
import com.example.spring_day6.model.User;
public interface AuthService {
User register(String username, String password);
User login(String username, String password);
}
- AuthServiceImpl
- 인메모리 방식으로 구현하기 위해 Service 내에 사용자 목록을 저장할
Map을 생성하고, Map에 저장된 사용자 정보를 읽어오거나 사용자를 추가한다.
- 인메모리 방식으로 구현하기 위해 Service 내에 사용자 목록을 저장할
package com.example.spring_day6.service;
import com.example.spring_day6.model.User;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class AuthServiceImpl implements AuthService {
// DB 대신 Service에 사용자 목록을 저장한 Map을 활용
private final Map<String, User> users = new HashMap<>();
// ID 역할
private Long nextId = 1L;
// 가입
@Override
public User register(String username, String password) {
// Map에 저장된 사용자가 있는지 확인
if (users.containsKey(username)) {
throw new RuntimeException("Username already exists");
}
// 사용자 객체를 사용자 이름과 비밀번호로 생성
User user = new User(username, password);
user.setId(nextId++); // ID 자동 증가
// 맵에 추가
users.put(username, user);
return user;
}
// 로그인
@Override
public User login(String username, String password) {
// 저장된 사용자 중
User user = users.get(username);
if (user != null && user.getPassword().equals(password)) {
return user;
}
throw new RuntimeException("Invalid credentials");
}
}
RecipeRepository
Page인터페이스를 사용해서 여러Recipe객체가 들어올 때 페이징 처리를 한다.Page: 해당 페이지의 엔티티 목록, 페이지 여부 등을 담는다.Pageable: 페이지 요청 정보를 담는다.- 요청 페이지 번호, 페이지 크기, 정렬 기준 등을 저장한다.
- RecipeService
package com.example.spring_day6.service;
import com.example.spring_day6.model.Recipe;
import com.example.spring_day6.model.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface RecipeService {
Page<Recipe> findAll(Pageable pageable);
Page<Recipe> findByTitleContaining(String keyword, Pageable pageable);
Recipe findById(Long id);
Recipe save(Recipe recipe, String tags, User author);
void toggleLike(Long recipeId, User user);
}
- RecipeServiceImpl
package com.example.spring_day6.service;
import com.example.spring_day6.model.Recipe;
import com.example.spring_day6.model.Tag;
import com.example.spring_day6.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class RecipeServiceImpl implements RecipeService {
// 인메모리에서 메모리를 저장
private final Map<Long, Recipe> recipes = new HashMap<>();
private Long nextId = 1L;
@Autowired
private TagService tagService;
// 전체 조회
@Override
public Page<Recipe> findAll(Pageable pageable) {
List<Recipe> allRecipes = new ArrayList<>(recipes.values());
return createPage(allRecipes, pageable);
}
// 제목 기준으로 검색
@Override
public Page<Recipe> findByTitleContaining(String keyword, Pageable pageable) {
List<Recipe> filteredRecipes = recipes.values().stream()
.filter(recipe -> recipe.getTitle().toLowerCase().contains(keyword.toLowerCase()))
.collect(Collectors.toList());
return createPage(filteredRecipes, pageable);
}
private Page<Recipe> createPage(List<Recipe> allRecipes, Pageable pageable) {
// 최신순으로 정렬 (ID 역순)
allRecipes.sort((r1, r2) -> r2.getId().compareTo(r1.getId()));
// 페이지 시작 지점 설정
int start = (int) pageable.getOffset();
// 페이지 종료 지점을 (시작+페이지 수)와 레시피 개수 중 최소값을 결정
int end = Math.min(start + pageable.getPageSize(), allRecipes.size());
List<Recipe> pageContent;
if (start >= allRecipes.size()) {
pageContent = new ArrayList<>();
} else {
pageContent = allRecipes.subList(start, end);
}
return new PageImpl<>(pageContent, pageable, allRecipes.size());
}
@Override
public Recipe findById(Long id) {
Recipe recipe = recipes.get(id);
if (recipe == null) {
throw new RuntimeException("Recipe not found: " + id);
}
return recipe;
}
@Override
public Recipe save(Recipe recipe, String tags, User author) {
if (recipe.getId() == null) {
recipe.setId(nextId++);
}
// 작성자 설정
recipe.setAuthor(author);
// 태그 처리
Set<Tag> tagSet = new HashSet<>();
if (tags != null && !tags.trim().isEmpty()) {
String[] tagNames = tags.split(",");
for (String tagName : tagNames) {
tagName = tagName.trim();
if (!tagName.isEmpty()) {
Tag tag = tagService.findByName(tagName);
if (tag == null) {
tag = new Tag();
tag.setId((long) Math.abs(tagName.hashCode())); // 양수 ID 보장
tag.setName(tagName);
((TagServiceImpl) tagService).saveTag(tag);
}
tagSet.add(tag);
}
}
}
recipe.setTags(tagSet);
// 초기화
if (recipe.getLikes() == null) {
recipe.setLikes(new HashSet<>());
}
if (recipe.getComments() == null) {
recipe.setComments(new ArrayList<>());
}
recipes.put(recipe.getId(), recipe);
return recipe;
}
@Override
public void toggleLike(Long recipeId, User user) {
Recipe recipe = findById(recipeId);
if (recipe.getLikes().contains(user)) {
recipe.getLikes().remove(user);
} else {
recipe.getLikes().add(user);
}
}
}
CommentService
- CommentSerivce
package com.example.spring_day6.service;
import com.example.spring_day6.model.Comment;
import com.example.spring_day6.model.User;
public interface CommentService {
Comment addComment(Long recipeId, User user, String content);
}
- CommentServiceImpl
package com.example.spring_day6.service;
import com.example.spring_day6.model.Comment;
import com.example.spring_day6.model.Recipe;
import com.example.spring_day6.model.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@Service
public class CommentServiceImpl implements CommentService {
private final Map<Long, Comment> comments = new HashMap<>();
private Long nextId = 1L;
@Autowired
private RecipeService recipeService;
@Override
public Comment addComment(Long recipeId, User user, String content) {
Recipe recipe = recipeService.findById(recipeId);
Comment comment = new Comment();
comment.setId(nextId++);
comment.setRecipe(recipe);
comment.setUser(user);
comment.setContent(content);
comment.setCreatedAt(LocalDateTime.now());
comments.put(comment.getId(), comment);
// 레시피의 댓글 목록에 추가
recipe.getComments().add(comment);
return comment;
}
}
TagService
- TagService
package com.example.spring_day6.service;
import com.example.spring_day6.model.Tag;
import java.util.List;
public interface TagService {
List<Tag> findAll();
Tag findByName(String name);
}
- TagServiceImpl
package com.example.spring_day6.service;
import com.example.spring_day6.model.Tag;
import org.springframework.stereotype.Service;
import java.util.*;
@Service
public class TagServiceImpl implements TagService {
private final Map<Long, Tag> tags = new HashMap<>();
private final Map<String, Tag> tagsByName = new HashMap<>();
@Override
public List<Tag> findAll() {
return new ArrayList<>(tags.values());
}
@Override
public Tag findByName(String name) {
return tagsByName.get(name);
}
// 내부적으로 사용할 메서드
public Tag saveTag(Tag tag) {
tags.put(tag.getId(), tag);
tagsByName.put(tag.getName(), tag);
return tag;
}
}
Controller
- Controller에는 각 사용자와 레시피에 연관된 동작을 처리하는 Mapping을 추가한다.
- Controller에서 사용자의 요청을 처리할 비즈니스 로직을 가져오기 위해
Service인터페이스를 의존 주입(DI)한다.- Spring Boot에선 인터페이스를 의존 주입하면 자동으로 인터페이스의 구현체 클래스를 검색하여 주입한다.
HomeController
package com.example.spring_day6.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "redirect:/recipes";
}
}
AuthController
- 사용자의 로그인, 회원가입, 로그아웃 동작을 처리하는 Controller다.
package com.example.spring_day6.controller;
import com.example.spring_day6.model.User;
import com.example.spring_day6.service.AuthService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpSession;
@Controller
public class AuthController {
@Autowired
private AuthService authService;
@GetMapping("/login")
public String loginForm() {
return "login";
}
@PostMapping("/login")
public String login(@RequestParam String username, @RequestParam String password,
HttpSession session, Model model) {
try {
User user = authService.login(username, password);
session.setAttribute("user", user);
return "redirect:/recipes";
} catch (RuntimeException e) {
model.addAttribute("error", "아이디 또는 비밀번호가 잘못되었습니다.");
return "login";
}
}
@GetMapping("/register")
public String registerForm() {
return "register";
}
@PostMapping("/register")
public String register(@RequestParam String username, @RequestParam String password,
Model model) {
try {
authService.register(username, password);
return "redirect:/login";
} catch (RuntimeException e) {
model.addAttribute("error", "이미 존재하는 사용자명입니다.");
return "register";
}
}
@GetMapping("/logout")
public String logout(HttpSession session) {
session.invalidate();
return "redirect:/recipes";
}
}
RecipeController
package com.example.spring_day6.controller;
import com.example.spring_day6.model.Recipe;
import com.example.spring_day6.model.User;
import com.example.spring_day6.service.RecipeService;
import com.example.spring_day6.service.TagService;
import com.example.spring_day6.service.CommentService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpSession;
import java.time.LocalDateTime;
import java.util.Collections;
@Controller
@RequestMapping("/recipes")
public class RecipeController {
@Autowired
private RecipeService recipeService;
@Autowired
private TagService tagService;
@Autowired
private CommentService commentService;
@GetMapping
public String recipes(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "") String keyword,
@RequestParam(defaultValue = "") String tag,
Model model,
HttpSession session) {
try {
Pageable pageable = PageRequest.of(page, 4); // 페이지당 4개로 변경
Page<Recipe> recipePage;
if (!keyword.isEmpty()) {
recipePage = recipeService.findByTitleContaining(keyword, pageable);
} else {
recipePage = recipeService.findAll(pageable);
}
model.addAttribute("page", recipePage);
model.addAttribute("keyword", keyword);
model.addAttribute("tag", tag);
model.addAttribute("tags", tagService.findAll() != null ? tagService.findAll() : Collections.emptyList());
model.addAttribute("user", session.getAttribute("user")); // 로그인 상태 확인용
} catch (Exception e) {
// 에러 발생시 빈 페이지 처리
model.addAttribute("page", null);
model.addAttribute("keyword", keyword);
model.addAttribute("tag", tag);
model.addAttribute("tags", Collections.emptyList());
model.addAttribute("user", session.getAttribute("user"));
}
return "recipes";
}
@GetMapping("/{id}")
public String recipeDetail(@PathVariable Long id, Model model, HttpSession session) {
try {
Recipe recipe = recipeService.findById(id);
model.addAttribute("recipe", recipe);
model.addAttribute("user", session.getAttribute("user"));
} catch (Exception e) {
model.addAttribute("recipe", null);
model.addAttribute("user", session.getAttribute("user"));
}
return "recipe_detail";
}
@GetMapping("/new")
public String newRecipeForm(HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
return "redirect:/login";
}
return "recipe_form";
}
@PostMapping
public String createRecipe(@RequestParam String title,
@RequestParam String description,
@RequestParam(defaultValue = "") String tags,
HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
return "redirect:/login";
}
try {
Recipe recipe = new Recipe();
recipe.setTitle(title != null ? title : "제목 없음");
recipe.setDescription(description != null ? description : "설명 없음");
recipe.setCreatedAt(LocalDateTime.now());
// 여기가 수정된 부분: user 파라미터 추가
recipeService.save(recipe, tags, user);
} catch (Exception e) {
// 에러 발생시에도 목록으로 리다이렉트
}
return "redirect:/recipes";
}
@PostMapping("/{id}/like")
public String toggleLike(@PathVariable Long id, HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
return "redirect:/login";
}
try {
recipeService.toggleLike(id, user);
} catch (Exception e) {
// 에러 발생시에도 상세 페이지로 리다이렉트
}
return "redirect:/recipes/" + id;
}
@PostMapping("/{id}/comments")
public String addComment(@PathVariable Long id,
@RequestParam String content,
HttpSession session) {
User user = (User) session.getAttribute("user");
if (user == null) {
return "redirect:/login";
}
try {
if (content != null && !content.trim().isEmpty()) {
commentService.addComment(id, user, content);
}
} catch (Exception e) {
// 에러 발생시에도 상세 페이지로 리다이렉트
}
return "redirect:/recipes/" + id;
}
}
view
- 수업 때 받은 실습 코드로, CSS는 미리 지정된 내용을 적용했다.
- Thymeleaf를 사용해서 Controller에서 Model에 담아 전송한 데이터를 출력할 수 있다.
index
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Home</title>
<meta http-equiv="refresh" content="0;url=/recipes"/>
</head>
<body></body>
</html>
login
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 400px; margin: 100px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h2 { text-align: center; margin-bottom: 30px; color: #333; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }
.form-group input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.btn-login { width: 100%; background: #007bff; color: white; border: none; padding: 12px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; margin-bottom: 15px; }
.btn-login:hover { background: #0056b3; }
.register-link { text-align: center; }
.register-link a { color: #007bff; text-decoration: none; }
.register-link a:hover { text-decoration: underline; }
.error-message { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
</style>
<script th:if="${error}">
alert('[[${error}]]');
</script>
</head>
<body>
<div class="container">
<h2>로그인</h2>
<div th:if="${error}" class="error-message" th:text="${error}">에러 메시지</div>
<form th:action="@{/login}" method="post">
<div class="form-group">
<label for="username">사용자명</label>
<input type="text" id="username" name="username" required/>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required/>
</div>
<button type="submit" class="btn-login">로그인</button>
</form>
<div class="register-link">
<a th:href="@{/register}">회원가입</a>
</div>
</div>
</body>
</html>
register
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>회원가입</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 400px; margin: 100px auto; background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h2 { text-align: center; margin-bottom: 30px; color: #333; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }
.form-group input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.btn-register { width: 100%; background: #28a745; color: white; border: none; padding: 12px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; margin-bottom: 15px; }
.btn-register:hover { background: #218838; }
.login-link { text-align: center; }
.login-link a { color: #007bff; text-decoration: none; }
.login-link a:hover { text-decoration: underline; }
.error-message { background: #f8d7da; color: #721c24; padding: 10px; border-radius: 4px; margin-bottom: 20px; border: 1px solid #f5c6cb; }
</style>
<script th:if="${error}">
alert('[[${error}]]');
</script>
</head>
<body>
<div class="container">
<h2>회원가입</h2>
<div th:if="${error}" class="error-message" th:text="${error}">에러 메시지</div>
<form th:action="@{/register}" method="post">
<div class="form-group">
<label for="username">사용자명</label>
<input type="text" id="username" name="username" required/>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input type="password" id="password" name="password" required/>
</div>
<button type="submit" class="btn-register">회원가입</button>
</form>
<div class="login-link">
<a th:href="@{/login}">로그인으로 돌아가기</a>
</div>
</div>
</body>
</html>
recipes
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>레시피 공유 커뮤니티</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 1000px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }
.header h1 { color: #333; margin: 0; }
.auth-info { display: flex; align-items: center; gap: 10px; }
.btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; font-size: 14px; }
.btn:hover { background: #f8f8f8; }
.btn-primary { background: #007bff; color: white; border-color: #007bff; }
.btn-primary:hover { background: #0056b3; }
.search-section { background: #f8f9fa; padding: 20px; border-radius: 4px; margin-bottom: 20px; }
.search-form { display: flex; gap: 10px; align-items: center; }
.search-input { flex: 1; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
.search-select { padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; }
.new-recipe { text-align: center; margin-bottom: 30px; }
.btn-new { background: #28a745; color: white; padding: 12px 24px; border: none; border-radius: 4px; text-decoration: none; font-weight: bold; }
.btn-new:hover { background: #218838; }
.recipes-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; }
.recipe-card { border: 1px solid #ddd; border-radius: 4px; padding: 20px; background: white; }
.recipe-title { font-size: 1.2em; font-weight: bold; margin-bottom: 10px; color: #333; }
.recipe-meta { color: #666; font-size: 0.9em; margin-bottom: 15px; }
.recipe-actions { text-align: center; }
.pagination { text-align: center; margin-top: 30px; }
.pagination a { margin: 0 5px; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; color: #333; }
.pagination a:hover { background: #f8f8f8; }
.no-recipes { text-align: center; padding: 40px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>레시피 공유 커뮤니티</h1>
<div class="auth-info">
<div th:if="${user != null}">
<span>안녕하세요, <strong th:text="${user?.username}">사용자</strong>님!</span>
<a th:href="@{/logout}" class="btn">로그아웃</a>
</div>
<div th:if="${user == null}">
<a th:href="@{/login}" class="btn">로그인</a>
<a th:href="@{/register}" class="btn btn-primary">회원가입</a>
</div>
</div>
</div>
<div class="search-section">
<form th:action="@{/recipes}" method="get" class="search-form">
<input type="text" name="keyword" placeholder="레시피 제목으로 검색..." th:value="${keyword ?: ''}" class="search-input"/>
<select name="tag" class="search-select">
<option value="">모든 태그</option>
<option th:each="t : ${tags ?: {}}" th:value="${t?.name}" th:text="${t?.name}" th:selected="${t?.name == tag}"></option>
</select>
<button type="submit" class="btn btn-primary">검색</button>
</form>
</div>
<div class="new-recipe">
<a th:href="@{/recipes/new}" class="btn-new">새 레시피 등록</a>
</div>
<div th:if="${page == null || #lists.isEmpty(page.content)}" class="no-recipes">
<h3>등록된 레시피가 없습니다</h3>
<p>첫 번째 레시피를 등록해보세요!</p>
</div>
<div th:if="${page != null && !#lists.isEmpty(page.content)}" class="recipes-grid">
<div th:each="recipe : ${page.content}" class="recipe-card">
<h3 class="recipe-title" th:text="${recipe?.title ?: '제목 없음'}">레시피 제목</h3>
<div class="recipe-meta">
<span th:text="${recipe?.createdAt != null ? #temporals.format(recipe.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span>
</div>
<div class="recipe-actions">
<a th:href="@{/recipes/{id}(id=${recipe?.id})}" class="btn btn-primary">자세히 보기</a>
</div>
</div>
</div>
<div th:if="${page != null && page.totalPages > 1}" class="pagination">
<a th:if="${page.hasPrevious()}"
th:href="@{/recipes(page=${page.number-1}, keyword=${keyword}, tag=${tag})}">이전</a>
<span th:text="'페이지 ' + (${page.number}+1) + ' / ' + ${page.totalPages}">페이지 1 / 1</span>
<a th:if="${page.hasNext()}"
th:href="@{/recipes(page=${page.number+1}, keyword=${keyword}, tag=${tag})}">다음</a>
</div>
</div>
</body>
</html>
recipe_detail
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title th:text="${recipe?.title ?: '레시피 상세'}">레시피 상세</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 800px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }
.back-btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; }
.back-btn:hover { background: #f8f8f8; }
.recipe-title { font-size: 1.8em; font-weight: bold; color: #333; margin-bottom: 15px; }
.recipe-meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
.recipe-author { color: #007bff; font-weight: bold; }
.recipe-description { line-height: 1.6; color: #555; background: #f8f9fa; padding: 20px; border-radius: 4px; margin-bottom: 20px; }
.tags-section { margin-bottom: 20px; }
.tags-label { font-weight: bold; margin-bottom: 10px; }
.tag { display: inline-block; background: #007bff; color: white; padding: 4px 8px; border-radius: 4px; font-size: 0.8em; margin-right: 5px; }
.like-section { text-align: center; margin: 30px 0; padding: 20px; background: #f8f9fa; border-radius: 4px; }
.btn-like { background: #dc3545; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
.btn-like:hover { background: #c82333; }
.btn-unlike { background: #007bff; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; }
.btn-unlike:hover { background: #0056b3; }
.comments-section { margin-top: 30px; }
.comments-title { font-size: 1.2em; font-weight: bold; margin-bottom: 20px; border-bottom: 1px solid #ddd; padding-bottom: 10px; }
.comment { background: #f8f9fa; padding: 15px; border-radius: 4px; margin-bottom: 15px; border-left: 3px solid #007bff; }
.comment-header { font-weight: bold; margin-bottom: 5px; }
.comment-meta { color: #666; font-size: 0.8em; }
.comment-content { margin: 10px 0; }
.comment-form { background: #f8f9fa; padding: 20px; border-radius: 4px; margin-top: 20px; }
.comment-form textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; resize: vertical; }
.comment-form button { background: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; margin-top: 10px; }
.comment-form button:hover { background: #218838; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>레시피 상세</h1>
<a th:href="@{/recipes}" class="back-btn">목록으로</a>
</div>
<h2 class="recipe-title" th:text="${recipe?.title ?: '제목 정보 없음'}">레시피 제목</h2>
<div class="recipe-meta">
<div>작성자: <span class="recipe-author" th:text="${recipe?.author?.username ?: '익명'}">작성자</span></div>
<div>작성일: <span th:text="${recipe?.createdAt != null ? #temporals.format(recipe.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span></div>
</div>
<div class="recipe-description" th:text="${recipe?.description ?: '설명이 없습니다.'}">레시피 설명</div>
<div class="tags-section" th:if="${recipe?.tags != null and !#lists.isEmpty(recipe.tags)}">
<div class="tags-label">태그:</div>
<span th:each="tag : ${recipe.tags}" class="tag" th:text="${tag?.name ?: '태그'}">태그</span>
</div>
<div class="like-section" th:if="${user != null}">
<form th:action="@{/recipes/{id}/like(id=${recipe?.id})}" method="post" style="display: inline;">
<button type="submit"
th:class="${recipe?.likes != null && #lists.contains(recipe.likes, user) ? 'btn-unlike' : 'btn-like'}"
th:text="${recipe?.likes != null && #lists.contains(recipe.likes, user) ? '좋아요 취소' : '좋아요'}">좋아요</button>
</form>
<span th:text="${recipe?.likes != null ? recipe.likes.size() : 0} + '명이 좋아합니다'">0명이 좋아합니다</span>
</div>
<div class="like-section" th:if="${user == null}">
<span th:text="${recipe?.likes != null ? recipe.likes.size() : 0} + '명이 좋아합니다'">0명이 좋아합니다</span>
<p><a th:href="@{/login}">로그인</a>하시면 좋아요를 누를 수 있습니다.</p>
</div>
<div class="comments-section">
<h3 class="comments-title">댓글</h3>
<div th:if="${recipe?.comments == null or #lists.isEmpty(recipe.comments)}">
<p>아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!</p>
</div>
<div th:each="comment : ${recipe?.comments ?: {}}" class="comment">
<div class="comment-header">
<span th:text="${comment?.user?.username ?: '익명'}">사용자</span>
<span class="comment-meta" th:text="${comment?.createdAt != null ? #temporals.format(comment.createdAt, 'yyyy-MM-dd HH:mm') : '날짜 정보 없음'}">2024-01-01 12:00</span>
</div>
<div class="comment-content" th:text="${comment?.content ?: '내용이 없습니다.'}">댓글 내용</div>
</div>
<div class="comment-form" th:if="${user != null}">
<form th:action="@{/recipes/{id}/comments(id=${recipe?.id})}" method="post">
<textarea name="content" rows="3" placeholder="댓글을 작성해주세요..." required></textarea>
<button type="submit">댓글 작성</button>
</form>
</div>
<div class="comment-form" th:if="${user == null}">
<p><a th:href="@{/login}">로그인</a>하시면 댓글을 작성할 수 있습니다.</p>
</div>
</div>
</div>
</body>
</html>
recipe_form
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>새 레시피 등록</title>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background-color: #f5f5f5; }
.container { max-width: 600px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 30px; padding-bottom: 15px; border-bottom: 2px solid #eee; }
.back-btn { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; text-decoration: none; background: white; color: #333; }
.back-btn:hover { background: #f8f8f8; }
.form-group { margin-bottom: 20px; }
.form-group label { display: block; margin-bottom: 5px; font-weight: bold; color: #333; }
.form-group input, .form-group textarea { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; }
.form-group textarea { resize: vertical; min-height: 120px; }
.btn-submit { background: #28a745; color: white; border: none; padding: 12px 30px; border-radius: 4px; cursor: pointer; font-size: 16px; font-weight: bold; }
.btn-submit:hover { background: #218838; }
.form-actions { text-align: center; margin-top: 30px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>새 레시피 등록</h1>
<a th:href="@{/recipes}" class="back-btn">목록으로</a>
</div>
<form th:action="@{/recipes}" method="post">
<div class="form-group">
<label for="title">레시피 제목 *</label>
<input type="text" id="title" name="title" required placeholder="레시피 제목을 입력하세요"/>
</div>
<div class="form-group">
<label for="description">레시피 설명 *</label>
<textarea id="description" name="description" required placeholder="레시피 만드는 방법을 자세히 설명해주세요"></textarea>
</div>
<div class="form-group">
<label for="tags">태그</label>
<input type="text" id="tags" name="tags" placeholder="태그를 쉼표로 구분해서 입력하세요 (예: 한식, 간단요리, 밥요리)"/>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit">레시피 등록</button>
</div>
</form>
</div>
</body>
</html>
테스트







